🎖️GitЯра🎖️
Commit 83851c0d4aaef44b0db73f620d18543d862fbdce
Parents : baef2a5
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-13T11:30:46Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-13T11:30:46Z
test(compose): prepare tests for re-landing CMP 1.12 (fixes 8 of 10 regressions) (#6666)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Changes
5 files changed, 75 insertions(+), 3 deletions(-)
Diff
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt
index 1d90866c33..88c0edb00e 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.testing.runUntilSettled
import org.meshtastic.core.testing.runWithRenderScope
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.User
@@ -217,7 +218,10 @@ class MeshNotificationManagerImplConversationTest {
// SERVICE_NOTIFY_ID is 101; a node whose num is also 101 used to overwrite the foreground notification.
manager.updateServiceStateNotification(ConnectionState.Connected, telemetry = null)
manager.showOrUpdateLowBatteryNotification(Node(num = 101), isRemote = false)
- advanceUntilIdle()
+ runUntilSettled {
+ systemNotificationManager.activeNotifications.any { it.id == 101 && it.tag == null } &&
+ activeByTag("low_battery").any { it.id == 101 }
+ }
val service = systemNotificationManager.activeNotifications.filter { it.id == 101 && it.tag == null }
val battery = activeByTag("low_battery").filter { it.id == 101 }
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
index 9dde4f433b..b7f24deaba 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
@@ -43,6 +43,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.local_stats_nodes
+import org.meshtastic.core.testing.runUntilSettled
import org.meshtastic.core.testing.runWithRenderScope
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Telemetry
@@ -121,7 +122,7 @@ class MeshNotificationManagerImplTest {
notifications.updateServiceStateNotification(ConnectionState.Disconnected, populatedTelemetry())
assertNull(activeServiceNotification())
- advanceUntilIdle()
+ runUntilSettled { activeServiceNotification() != null }
assertNotNull(activeServiceNotification())
}
@@ -167,7 +168,7 @@ class MeshNotificationManagerImplTest {
notifications.initChannels()
notifications.updateServiceStateNotification(ConnectionState.Disconnected, telemetry = null)
- advanceUntilIdle()
+ runUntilSettled { activeServiceNotification() != null }
val text = activeServiceNotification()?.notification?.extras?.getCharSequence(Notification.EXTRA_TEXT)
assertNotNull(text)
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/TestScopes.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/TestScopes.kt
index 210855b75d..b57f443e53 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/TestScopes.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/TestScopes.kt
@@ -17,11 +17,19 @@
package org.meshtastic.core.testing
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeSource
/** Runs a test with a structured child scope driven by the test scheduler and always cancels it afterward. */
fun runWithRenderScope(block: suspend TestScope.(CoroutineScope) -> Unit) = runTest {
@@ -32,3 +40,25 @@ fun runWithRenderScope(block: suspend TestScope.(CoroutineScope) -> Unit) = runT
renderScope.cancel()
}
}
+
+private val SETTLE_POLL_INTERVAL = 5.milliseconds
+
+/**
+ * Runs the test scheduler until [isSettled] holds, waiting in real time between passes.
+ *
+ * Compose resources are loaded on an internal `Dispatchers.Default` scope, so state derived from a string resource
+ * lands outside the test scheduler and cannot be observed by draining it.
+ *
+ * Not safe in a test that asserts on exact virtual time: waiting in real time suspends the test coroutine, and
+ * `runTest` advances the virtual clock to the next scheduled task whenever that happens, firing pending `delay`s early.
+ * Such a test should instead load the resources it needs before scheduling anything it later advances past.
+ */
+suspend fun TestScope.runUntilSettled(timeout: Duration = 10.seconds, isSettled: () -> Boolean) {
+ val start = TimeSource.Monotonic.markNow()
+ while (true) {
+ runCurrent()
+ if (isSettled()) return
+ check(start.elapsedNow() < timeout) { "condition was still not settled after $timeout" }
+ withContext(Dispatchers.Default) { delay(SETTLE_POLL_INTERVAL) }
+ }
+}
diff --git a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
index 5d1686db02..7f27f36293 100644
--- a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
+++ b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
@@ -33,6 +33,7 @@ import org.meshtastic.core.testing.FakeBleDevice
import org.meshtastic.core.testing.failBondAfterRecording
import org.meshtastic.core.testing.failBondWith
import org.meshtastic.core.testing.failBondWithSecurityException
+import org.meshtastic.core.testing.runUntilSettled
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@@ -135,6 +136,7 @@ class AndroidScannerViewModelBondingTest {
assertEquals(1, harness.bluetoothRepository.bondCalls.size)
assertNull(harness.radioController.lastSetDeviceAddress)
+ runUntilSettled { harness.serviceRepository.errorMessage.value != null }
assertEquals(getString(Res.string.bonding_failed_retry), harness.serviceRepository.errorMessage.value)
}
@@ -160,6 +162,7 @@ class AndroidScannerViewModelBondingTest {
assertEquals(1, harness.bluetoothRepository.bondCalls.size)
assertNull(harness.radioController.lastSetDeviceAddress)
+ runUntilSettled { harness.serviceRepository.errorMessage.value != null }
assertNotNull(harness.serviceRepository.errorMessage.value)
}
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
index 44db8cf3a5..c94867a839 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
@@ -31,9 +31,18 @@ import kotlinx.coroutines.test.setMain
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanStartException
import org.meshtastic.core.ble.BleScanStartFailureReason
+import org.meshtastic.core.common.util.safeCatchingAll
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.network.repository.DiscoveredService
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.bluetooth_disabled
+import org.meshtastic.core.resources.bluetooth_scan_location_services_disabled
+import org.meshtastic.core.resources.bluetooth_scan_missing_permission
+import org.meshtastic.core.resources.bluetooth_scan_start_failed
+import org.meshtastic.core.resources.bluetooth_scan_too_frequent
+import org.meshtastic.core.resources.getPluralStringSuspend
+import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.testing.FakeBleDevice
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.DiscoveredDevices
@@ -114,6 +123,7 @@ class ScannerViewModelTest {
@Test
fun `scan startup failure clears scanning state disables auto-scan and surfaces error`() = runTest {
+ warmScanFailureStrings()
harness.uiPrefs.setBleAutoScan(true)
every { bleScanner.scan(any(), any()) } returns failingScanFlow()
@@ -154,6 +164,7 @@ class ScannerViewModelTest {
@Test
fun `bluetooth-disabled failure allows an immediate retry once the user re-enables it`() = runTest {
+ warmScanFailureStrings()
// No cooldown for preconditions the user clears with a system toggle — a dead scan button right after they
// switched Bluetooth back on reads as a broken app.
var scanAttempts = 0
@@ -178,6 +189,7 @@ class ScannerViewModelTest {
@Test
fun `location-services-disabled failure allows an immediate retry`() = runTest {
+ warmScanFailureStrings()
var scanAttempts = 0
every { bleScanner.scan(any(), any()) } returns
flow {
@@ -201,6 +213,7 @@ class ScannerViewModelTest {
@Test
fun `scan quota failure honors retry-after cooldown`() = runTest {
+ warmScanFailureStrings()
var scanAttempts = 0
every { bleScanner.scan(any(), any()) } returns
flow {
@@ -728,3 +741,24 @@ class ScannerViewModelTest {
)
}
}
+
+/**
+ * Loads the scan-failure strings into the compose-resources cache up front.
+ *
+ * The first read of a resource completes on an internal `Dispatchers.Default` scope, so an un-warmed lookup lands after
+ * the caller has moved on. Warming keeps the error message observable synchronously, which these tests need because
+ * they also assert on exact virtual-time retry cooldowns and so cannot wait in real time.
+ *
+ * Best-effort via [safeCatchingAll], mirroring the ViewModel: the androidHostTest stubs leave `Resources.getSystem()`
+ * unmocked and skiko's initializer can raise an `Error` here, so resources never resolve and the untranslated fallback
+ * — identical text, produced synchronously — is what the assertions match. Cancellation still propagates.
+ */
+private suspend fun warmScanFailureStrings() {
+ safeCatchingAll {
+ getStringSuspend(Res.string.bluetooth_scan_start_failed)
+ getStringSuspend(Res.string.bluetooth_scan_missing_permission)
+ getStringSuspend(Res.string.bluetooth_disabled)
+ getStringSuspend(Res.string.bluetooth_scan_location_services_disabled)
+ getPluralStringSuspend(Res.plurals.bluetooth_scan_too_frequent, 1, 1L)
+ }
+}
Served by rngit 1.5.2 - Generated in 0.16s